Skip to content

fix(mongodb): retry a write conflict on the stream sequence counter - #475

Open
LeeroyHannigan wants to merge 12 commits into
mainfrom
fix/mongodb-stream-counter-conflict
Open

LeeroyHannigan wants to merge 12 commits into
mainfrom
fix/mongodb-stream-counter-conflict

Conversation

@LeeroyHannigan

@LeeroyHannigan LeeroyHannigan commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #437. A write to a stream-enabled table that spends its 50 attempts under
sustained contention answers 503 here where the service answers 200; #426 tracks
retrying a lost race until it commits.

On a table with a stream enabled, every write draws a sequence number from a
per-shard counter inside the write's transaction. Two writes on the same shard
conflict on that counter, MongoDB aborts one with WriteConflict (code 112) and
the TransientTransactionError label, and next_sequence_number_in_session
mapped that error to StorageError::Internal. The retry loop never saw the
conflict and the request answered HTTP 500. The same mapping sat on the index
entry writes, the shard lookup and the stream record insert.

write_conflict::transaction_error now classifies a driver error raised inside
a write transaction: a transient write conflict becomes
StorageError::Transient, which the transaction bodies turn into a re-run with
the same backoff and budget as a conflict on the data collection; anything else
stays Internal. The UnknownTransactionCommitResult label is not transient
and is still classified by the commit path alone, which retries
commitTransaction in place and never re-runs the body.

A single-item write that spends its whole retry budget returns
StorageError::Transient, answered as HTTP 503 ServiceUnavailable, from all
three operations: PutItem, DeleteItem and UpdateItem. TransactWriteItems
keeps TransactionCanceledException with a per-operation
TransactionConflict reason.

BatchWriteItem applied its requests one after another and answered any write
error for the whole batch, so a batch whose first put had committed answered
500 and a later PutItem returned that put's item as its old image. BatchWriteItem
now checks the whole batch before it writes anything. The table lookups, the
duplicate-key check and the validation of every request run first, and a request
fault (a missing table, a key that does not match the schema, duplicate keys, an
item over the size limit) rejects the batch with a 400 and nothing applied,
wherever the faulty request sits. This also removes a partial application that
exists on main, where the requests ahead of a faulty request were written before
the 400. The limits on the number of tables and requests are checked before
that, and the request size limit by the HTTP layer. The handler then tries every
request, in the order listed within a table and in the order the map yields the
tables. A request the backend cannot apply now (a transient error such as a
spent retry budget, or a transaction conflict) does not stop the others: it is
returned in UnprocessedItems, and the batch answers 200 when at least one
request applied. A connection error ends the attempt, and the request that met
it and every request after it are returned in UnprocessedItems. A batch of which
no request applied answers 503 ServiceUnavailable. A storage error that a replay
does not clear, such as an internal error, still fails the batch with a 500
after earlier requests were applied. So does a put or a delete on a Cassandra
table without TTL that meets an item a TransactWriteItems holds: it returns
TransactionCanceled and the batch answers 400 TransactionCanceledException.
Consumed capacity and item collection metrics count the applied requests only.
A request is applied at most once: the storage layer retries before commit and
never re-runs a body after a commit of unknown outcome. Each failed request
spends its own retry budget before the batch answers, so a batch of 25
contended requests can spend 25 budgets in one call.

What the service does

Every measurement below is against DynamoDB in us-east-1 with client retries
off (retries={"max_attempts": 0}), on existing bb- tables with a string
hash key, a GSI and a stream.

2026-10-23, one key, all writers on the same item:

shape calls outcome
16 threads x 30 PutItem on one key 480 480 answered 200, 0.6 s
64 threads x 30 PutItem on one key 1,920 1,920 answered 200, 2.5 s
16 writers x 30 rounds, 8 BatchWriteItem (hot key plus a side key) racing 8 PutItem with ALL_OLD on the hot key 240 batch and 240 put all 200, no UnprocessedItems, 0.6 s
16 threads x 20 UpdateItem ADD #c :one SET #g = :g with ALL_NEW on one key 320 320 answered 200, 0.5 s
16 threads x 20 UpdateItem SET l = list_append(l, :m) on one key 320 320 answered 200, 0.5 s
16 threads x 30 PutItem each on its own key of the GSI table 480 480 answered 200, 0.6 s
64 threads x 30 PutItem each on its own key of the GSI table 1,920 1,920 answered 200, 2.3 s

The UpdateItem rows also hold for what the accepted calls did: the stored
counter was 320, equal to the number of calls that answered 200, the 320
ALL_NEW counter values were 1 to 320 with each returned to exactly one caller,
and the stored list held each of the 320 markers once, none missing and none
duplicated.

The service sheds none of this load at any writer count measured. It answers a
single-item write with TransactionConflictException only while a
TransactWriteItems holds the item, which this backend cannot tell apart from
a race against other single-item writes.

BatchWriteItem racing TransactWriteItems, 2026-10-27. Four threads looped
TransactWriteItems over two items while four threads sent BatchWriteItem calls
on the same items. Two samples of 640 batch calls each; about half of the
transactions were cancelled by each other (107 of 222 and 125 of 240), so the
items were held often.

batch answer calls, sample 1 and 2
two requests, one on a held item and one on an item no transaction touches, held item first 200, the held item's request in UnprocessedItems, the other request applied 85 and 101
the same, held item second 200, the held item's request in UnprocessedItems, the other request applied 75 and 80
two requests on two held items 200 with the one request that lost in UnprocessedItems 10 and 21
two requests on two held items 500 InternalServerError 80 and 68
one request on a held item 500 InternalServerError 78 and 82 of 160

The item no transaction touched was present after all 640 calls of both
samples. No call answered TransactionConflictException (0 of 1,280) and none
answered 200 with every request unprocessed. So the batch handler keeps
TransactionConflict deferrable, and a batch of which no request applied answers
an error, where this server answers 503.

BatchWriteItem faults, 2026-10-27. A batch of a valid put followed by a put with
a key of the wrong type, a put to a missing table, or duplicate keys in a second
table answered 400 (ValidationException, ResourceNotFoundException,
ValidationException) and applied none of its requests; a delete followed by a
put with a key of the wrong type answered 400 ValidationException and the item
was still present. Two samples of each.

What each status means here

  • 200: the write committed.
  • 200 with UnprocessedItems (BatchWriteItem): the listed requests were not
    applied and the client replays them. The API reference puts a request that
    failed because the throughput was exceeded or an internal processing failure
    occurred into UnprocessedItems, and lists the request faults that reject the
    whole batch: a missing table, a key that does not match the schema,
    duplicate keys, more than 25 requests, an item over 400 KB, a request over
    16 MB, a key over the key length limits, none of which is contention. The
    one error the page names for a batch that processed nothing is a throttle
    (ProvisionedThroughputExceededException or ThrottlingException), which
    this server does not account for. A batch of which no request could be
    applied answers 503 ServiceUnavailable, which the SDKs retry with backoff, so
    a client's UnprocessedItems loop does not spin on a batch that makes no
    progress. The service answers 500 InternalServerError for that case (measured
    below).
  • 503 ServiceUnavailable: a single-item write lost every attempt of its retry
    budget, or a batch applied none of its requests.
    The error-handling documentation lists ServiceUnavailable (HTTP 503) as
    "OK to retry? Yes", and the SDK retry modes retry it, whereas a 400
    TransactionConflictException is handed to the application.
  • 500: a storage fault. An internal error is not deferred into
    UnprocessedItems, because replaying the request does not clear it and a
    client's UnprocessedItems loop would spin on it.

The known limit this PR does not change

MongoDB aborts the loser of a same-document conflict at once instead of
queueing it, so a hot key is settled by retries. Measured on MongoDB 7.0 as a
single-node replica set with a stream and GSI table, client retries off, on a
host shared with other sessions (load average 17 to 20), 64 writers x 20
PutItem on one key: 337 of 1,280 calls committed, a commit rate of 6.5 per
second; at 256 writers, 298 of 5,120, a rate of 2.4 per second. The service
answered 1,920 of 1,920 calls from 64 writers on one key in 2.5 s, about 770
per second. The difference
comes from aborting the loser rather than queueing it, which this PR does not
change. This PR changes the answer a losing write gets to a 503 the SDKs retry,
in place of a 500 or a 400. #426 tracks retrying a lost race until it commits.

Tests

tests/test_stream_write_contention.py:

  • test_racing_puts_and_deletes_on_a_stream_table_all_succeed: 8 writers x 60
    operations over 4 keys of a stream table, every call answers 200, and the
    stream holds one record per write that changed the item.
  • test_batch_writes_racing_puts_on_a_stream_table_never_fail: 16 writers, 8
    sending BatchWriteItem (the raced key plus a per-writer side key) and 8
    sending PutItem on the raced key, 30 rounds. Even rounds put the raced
    request first, odd rounds put the side request first, so both positions are
    covered. A batch call answers 200 when at least one request applied and 503 when none
    did, a batch that did not apply every request lists the rest in
    UnprocessedItems, replaying those lands each
    side item once, and the stream record count equals the number of applied
    writes. A PutItem may answer 503 once it has spent its budget and nothing
    else.
  • test_update_item_contention_is_never_a_transaction_conflict: 16 writers x
    20 ADD #c :one SET #g = :g and 16 x 20 SET l = list_append(l, :m) on one key of a table
    with a GSI and a stream, the shape measured above. No call may answer 400
    TransactionConflictException, the stored counter must equal the number of
    calls that answered 200, each ALL_NEW counter value must go to exactly one
    caller, and every marker a caller was told committed must be stored once.

Engine unit test only_errors_a_replay_may_clear_are_deferred pins the
deferrable set: a transaction conflict, a transient error and a connection
error are deferred; an internal error, a validation error, a missing table, a
failed condition and an exceeded limit are not.

Further tests on this branch:

  • crates/engine/src/batch_write_item/write_batch_tests.rs: a storage double
    whose first put fails with Transient, followed by a request whose key does not
    match the schema, answers 400 with nothing written; the same fault in a later
    table, a missing later table and duplicate keys in a later table; a valid
    batch written with each table's own key information; a put that returns
    TransactionConflict after another request applied leaves exactly that request
    in UnprocessedItems; and one test per pre-write check (item size, nesting
    depth, attribute-name size, key size, key-only delete shape) with the fault
    in the second request, asserting 400 and no write. Moving any one of those
    checks back inside the write loop fails its test.
  • crates/engine/src/batch_write_item/write_batch_deferral_tests.rs: a failed
    first request does not stop the rest; UnprocessedItems holds exactly the
    failed requests per table and the consumed capacity counts the applied ones; a
    double that fails every put answers 503 after trying every request, for
    Transient and for TransactionConflict; a connection error leaves the rest
    unattempted; an internal error after a failed request still fails the batch
    with 500.
  • tests/test_batch_write_validation_order.py: a valid put or delete ahead of
    an invalid put, a put to a missing table, a table with duplicate keys, an item
    of 401 KB, or a delete with an extra key attribute answers 400 with the valid
    request not applied. It needs no contention.
  • crates/storage-mongodb: the exhausted-budget error carries the operation,
    the attempt count and the last error that ended an attempt, and the
    conversions into the retry loops keep the driver's text.
  • test_transactions_on_a_stream_table_all_commit: six writers send
    TransactWriteItems (a put, a delete and an update on disjoint keys) to a
    stream table. Every call must commit, a cancellation by TransactionConflict
    after 50 attempts is resent, and each counter and the stream record count
    show every transaction applied once.
  • The batch replay loop in the stream contention test stops after 200 passes
    with a failing assertion. ServiceUnavailable is a retryable code in
    tests/test_concurrency.py. On Cassandra the batch and UpdateItem contention
    tests accept 400 TransactionConflictException for a lost single-item race,
    which is that backend's answer once its retries are spent, and the
    transaction test expects stream records for the setup puts only, because
    Cassandra writes no record for a transacted write until PR fix(cassandra): record stream records for transacted writes #576 lands.

What fails without the fix

On main (9d13202), measured on MongoDB 7.0: both tests in
test_stream_write_contention.py fail, 88 of 480 churn writes and 9 batch calls
answering 500 InternalServerError.

The wider comparison measured while this branch was written, same backend,
client retries off:

shape main (9d13202) 879a419
PutItem and DeleteItem churn over 4 keys, 8 threads, 2,016 calls 286 PutItem and 136 DeleteItem answered 500 no errors
the same churn at 32 threads, 2,016 calls 500s on the same shape 94 of 1,024 PutItem answered 503, no DeleteItem error
BatchWriteItem racing PutItem on one key, 16 writers, 480 calls 63 BatchWriteItem and 1 PutItem answered 500, and 25 PutItem ALL_OLD images were items of a batch that had answered an error 8 BatchWriteItem and 7 PutItem answered 503, no such image

The 503s in that table are the 50-attempt budget running out. #426 tracks
retrying a lost race until it commits. The server log on the branch carries no WriteConflict line and no
internal error for these runs.

On 879a419, the first commit of this branch, with the test file of this
branch, over 8 runs: the UpdateItem test failed 7 runs, 1 to 25 of 640 calls
answering 400 TransactionConflictException; the batch order test failed 2 runs,
both with BatchWriteItem 503 ServiceUnavailable on a round that put the raced
request first. With the fix in the tree, 8 of 8 runs passed all three tests.

Controls on the committed tree with the change reverted or the server edited to
force the case:

  • Validate-then-write order restored in the batch handler: 4 of the 7 engine
    batch tests and all 5 original tests of tests/test_batch_write_validation_order.py
    fail; the first head's binary fails the same 5 wire tests. With item size and
    the key-only delete shape moved back inside the write loop, the two tests of
    those checks fail and the two new wire parameters fail at the not-applied
    assertion.
  • Stop at the first failed request restored and the zero-progress answer
    removed: 5 of 14 engine batch tests fail.
  • A helper conflict inside TransactWriteItems answered 503 instead of re-running
    the transaction: the transaction test fails with 42 of 60 transactions
    answering 503.
  • A server that never applies a batch: the stream contention test fails after
    four seconds at the 200-pass limit; the test without the limit does not finish
    within 60 seconds.
  • A server that answers the exhausted-budget 503 to every fifth UpdateItem: the
    three single-row classes of tests/test_concurrency.py fail 3 of 3 runs
    without ServiceUnavailable in the retryable codes and pass 3 of 3 with it.

The batch order control is probabilistic: it fires when the first request of a
batch spends its 50-attempt budget.

Gates

  • cargo +1.97.0 fmt --all -- --check: clean.
  • cargo +1.97.0 clippy --all-targets -- -D warnings: no warnings.
  • cargo test --workspace and cargo +1.88.0 test --workspace: 1,835 passed,
    0 failed, 26 ignored on each. The Cassandra integration targets ran as unit
    tests only, because another process on the build host holds a Cassandra on
    127.0.0.1:9042 that those targets would otherwise use.
  • MongoDB wire tests (devtools/run-tests --backend mongodb --pytest) for
    test_stream_write_contention, test_batch_operations, test_item_operations
    and test_put_item_create_race: 170 passed, 3 xfailed, 0 failed. The three
    xfails are the lost-create-race 500s of [Bug] MongoDB: concurrent same-key PutItem surfaces E11000 as InternalServerError #356, which fix(mongodb): retry an unconditional PutItem that loses the create race #431 fixes and which
    this branch's base does not carry.
  • SQLite wire tests for test_batch_operations and test_item_operations, the
    shared engine paths that batch_write_item.rs serves on every backend: 161
    passed, 0 failed.
  • Cargo.lock unchanged, so the license notice checks do not apply.

On a table with a stream enabled, every write draws a sequence number from
a per-shard counter inside the write's transaction. Two writes on the same
shard conflict on that counter, MongoDB aborts one with WriteConflict (code
112) and the TransientTransactionError label, and
next_sequence_number_in_session mapped the error to StorageError::Internal.
The transaction retry loop never saw the conflict and the request answered
HTTP 500. The same mapping sat on the index entry writes, the shard lookup
and the stream record insert.

A new write_conflict::transaction_error classifies a driver error raised
inside a write transaction: a transient write conflict becomes
StorageError::Transient, anything else stays Internal. The in-session
helpers use it, and TxErr and TransactOpError turn a Transient error from
a helper into a re-run of the transaction, with the same backoff and budget
as a conflict on the data collection. The UnknownTransactionCommitResult
label is not transient and is still classified by the commit path alone.

A PutItem or DeleteItem that runs out of its retry budget now returns
StorageError::Transient, which the engine answers as HTTP 503
ServiceUnavailable, instead of a 400 TransactionConflictException. Amazon
DynamoDB answers a single-item write with TransactionConflictException
only while a TransactWriteItems holds the item, which this backend cannot
tell apart from a race against other single-item writes. The DynamoDB
error-handling documentation lists ServiceUnavailable (HTTP 503) as "OK to
retry? Yes", and the SDK retry modes retry a 503 on their own, whereas a
400 is handed to the application. UpdateItem keeps its own exhaustion
error.

BatchWriteItem applied its requests one after another and answered any
write error for the whole batch, so a batch whose first put had committed
answered 500 and a later PutItem returned that put's item as its old
image. The handler now keeps the error only when no request was applied.
After a committed request, a write the backend could not apply now (a
spent retry budget, a transient, connection or internal failure) and every
request after it are reported in UnprocessedItems with a 200, which is
what the service documents for a batch with individual failures. Consumed
capacity and item collection metrics count the applied requests only. A
request is applied at most once: the storage layer retries before commit
and never re-runs a body after a commit of unknown outcome.

Measured on 2026-10-23 against the service in us-east-1 with client
retries off: PutItem and DeleteItem churn over 4 keys from 8 threads, 480
calls, all 200; BatchWriteItem racing PutItem on one key from 16 writers,
240 calls, all 200 and no UnprocessedItems. On MongoDB 7.0 (single-node
replica set, client never retries): the same churn with 2,016 calls
answered 286 PutItem and 136 DeleteItem with 500 on main and 0 on this
branch; 16 writers racing BatchWriteItem against PutItem on one key, 480
calls: main answered 63 BatchWriteItem and 1 PutItem with 500 and 25
PutItem ALL_OLD images were items of a batch that had failed; this branch
answered 8 BatchWriteItem and 7 PutItem with 503 and no such image. With
32 threads on the churn shape 94 of 1,024 PutItem calls still run out of
the 50-attempt budget and answer 503; the budget is the subject of #426.
tests/test_stream_write_contention.py fails on main (88 of 480 churn
writes and 10 batch calls answered 500) and passes on this branch.

Fixes #437
…retry budget

UpdateItem returned TransactionConflict when its retry budget ran out,
while PutItem and DeleteItem returned the transient error the engine
answers as HTTP 503 ServiceUnavailable. One backend gave two answers to
the same contention on the same item, and the 400 is the answer the
application sees rather than the one the SDKs retry. UpdateItem now
returns write_conflict::retry_budget_exhausted as well.
TransactWriteItems keeps TransactionCanceledException with a per-operation
TransactionConflict reason, which is what the service answers for a
transaction.

Measured on 2026-10-23 against the service in us-east-1, client retries
off, on a table with a GSI and a stream: 16 writers x 20 UpdateItem
ADD c :1 on one key answered 320 of 320 with 200, the stored counter
equalled the number of calls that answered 200, and the 320 ALL_NEW
counter values were 1 to 320, each returned to one caller; 16 writers x
20 SET l = list_append(l, :m) answered 320 of 320 with 200 and the stored
list held each marker once. The service answers a single-item write with
TransactionConflictException only while a TransactWriteItems holds the
item, which none of these callers sent.

BatchWriteItem deferred a failed write into UnprocessedItems only when an
earlier request in the same batch had been applied, so the same
contention answered 200 with UnprocessedItems or 503 for the whole batch
depending on where the contended request sat in the batch. A deferrable
failure now goes to UnprocessedItems wherever it sat, and a batch that
could apply none of its requests answers 200 with every request in
UnprocessedItems. The BatchWriteItem documentation puts a request that
failed because the throughput was exceeded or an internal processing
failure occurred into UnprocessedItems, lists the request faults that
reject the whole batch (a missing table, a key that does not match the
schema, duplicate keys, more than 25 requests, an item over 400 KB, a
request over 16 MB, an oversized key), and names an error for a batch
that processed nothing only for throttling, which this server does not
account for.

A storage fault is no longer deferred: is_deferrable drops
StorageError::Internal, so an internal error keeps answering HTTP 500
instead of being handed to a client's UnprocessedItems loop, which a
replay cannot clear.

tests/test_stream_write_contention.py gains the UpdateItem shape above on
a table with a GSI and a stream: no call answers a 400
TransactionConflictException, the counter matches the calls that answered
200, and every marker a caller was told committed is stored once. Its
BatchWriteItem test now sends the contended request first as well as
last, which is the order that answered 503 for the whole batch.

The RFC's write-conflict paragraph said is_transient_write_conflict
matches the UnknownTransactionCommitResult label. It does not, by design:
under that label the commit may already have applied, so the body must
not be re-run, and classify_commit_error handles it alone.

Issue #437
The BatchWriteItem contention test sent the raced put after a side put in
every round, the order in which an earlier request has already committed
when the raced one fails. It now alternates: the raced put leads on even
rounds and the side put on odd rounds, and a failure records which arm
raised it and which order the round used. The writer count goes to 16, 8
batch writers racing 8 putters, the shape measured against the service
(240 BatchWriteItem and 240 PutItem on one key, all 200).

The batch arm must not fail in either order. A single-item PutItem that
spends its whole retry budget answers 503 ServiceUnavailable, which the
SDKs retry, so the put arm allows that one answer and no other.

With the sources of 879a419 in the tree and this test file, over 8 runs
against MongoDB 7.0: the batch test failed 2 runs, both with
BatchWriteItem 503 ServiceUnavailable on a raced-first round, and the
UpdateItem test failed 7 runs, with 1 to 25 of 640 calls answering 400
TransactionConflictException. With the fixes in the tree, 8 of 8 runs
passed all three tests.

Issue #437
The handler looked up each table, checked its duplicate keys and validated
each request immediately before writing that request. A write that could not
be applied now (a spent retry budget or a connection fault) ended the loop
and returned every later request in UnprocessedItems with a 200, so a later
request that fails validation was never checked and came back as unprocessed.
Without any contention the same order already applied the requests ahead of a
faulty one and then answered 400, so a rejected batch could leave part of
itself applied.

Every table lookup, the duplicate-key check and the validation of every
request now run before the first write, in the order the faults used to be
reached. A fault rejects the whole batch with nothing applied, wherever the
faulty request sits, which is what the BatchWriteItem reference documents for
these faults. This also closes the partial application that preceded a later
request's validation error. The writes run in a separate function that takes
a DataEngine, so the handler tests drive it with a storage double.

Tests: write_batch_tests has a double whose first put fails with Transient
and a second request whose key does not match the schema (400, nothing
written), the same fault in a later table, a missing later table, duplicate
keys in a later table, and a valid batch written with each table's own key
info. tests/test_batch_write_validation_order.py sends a valid put or delete
ahead of an invalid put, a put to a missing table, or a table with duplicate
keys, and requires a 400 with the valid request not applied. It needs no
contention.
The batch handler defers TransactionConflict, which the Cassandra backend
raises from a put when a transaction or an expiration claim holds the item.
No measurement of the service backed that, so it was open whether the batch
should defer the request or fail with TransactionConflictException.

Measured on the service (us-east-1, 2026-10-27, client retries off): four
writers ran TransactWriteItems on the same two items in a loop while four
batch writers sent BatchWriteItem calls on them, two samples of 640 batch
calls. About half of the transactions were cancelled by each other (107 of
222 and 125 of 240), so the items were held often.

- A batch of two requests, one on a held item and one on an item no
  transaction touches, answered 200 with the held item's request in
  UnprocessedItems and the other request applied, in either order. The held
  item came first in 85 and 101 of the calls and second in 75 and 80 of them
  (first and second sample). The other item was present after all 640 calls.
- A batch of two requests on two held items answered 200 with the one
  request that lost in UnprocessedItems in 10 and 21 calls, and 500
  InternalServerError in 80 and 68 calls. No call answered 200 with both
  requests unprocessed.
- A batch of one request on a held item answered 500 InternalServerError in 78
  and 82 of 160 calls.
- No BatchWriteItem call answered TransactionConflictException.

So TransactionConflict stays deferrable, and the handler test pins the
answer: a put that returns it after another request applied leaves exactly
that request in UnprocessedItems with a 200. The Cassandra side of the pin is
the existing storage test in ttl_integration.rs, which holds a claim on a row
and asserts that a put on it returns TransactionConflict. The answer for a
batch where nothing applied is a separate decision, taken in the next commit.
The handler stopped at the first request the backend could not apply and
returned that request and every request after it in UnprocessedItems with a
200. Two things followed. The answer for a contended request depended on its
position in the batch, and a batch of which no request applied answered 200
with every request unprocessed. The boto3 batch writer adds unprocessed items
to its next flush with no pause, so under the contention that caused the
failure such a client loops at full speed, and each pass costs the server
another full retry budget.

The service does neither (measured 2026-10-27 in us-east-1, client retries
off, while a TransactWriteItems held the items). A batch of two requests in
which one was on a held item answered 200 with that request unprocessed and
the other applied, whichever came first; the other request applied in all 640
calls of both samples. A batch whose requests were all on held items, one
request or two, answered 500 InternalServerError, and no call answered 200
with every request unprocessed.

The handler now tries every request. A request that fails with an error a
replay may clear is added to UnprocessedItems and the loop goes on. When no
request applied, the batch answers 503 ServiceUnavailable, which the SDKs
retry with backoff; the service's 500 for the same case is recorded in
docs/differences-from-dynamodb.md. A connection error still ends the attempt,
because the requests after it would wait on the same unreachable backend: the
request that met it and the rest are returned unprocessed, or the batch
answers 503 if nothing applied.

Trade-off: each failed request spends its own retry budget before the batch
answers, so a batch of 25 contended requests can spend 25 budgets in one call
where it spent one before. The 50-attempt budget still bounds each of them.

Tests (write_batch_deferral_tests): a failed first request does not stop the
rest; UnprocessedItems holds exactly the failed requests per table and the
consumed capacity and WriteCapacityUnits count the applied ones; a double
that fails every put gives 503 after trying every request, for Transient and
for TransactionConflict; a connection error leaves the rest unattempted, and
with nothing applied answers 503 after one attempt; an internal error after a
failed request still fails the batch with 500. The shared fixtures moved into
scripted.rs next to the double. The stream contention test retries a batch
that answers 503, as an SDK does.
When a single-item write ran out of attempts, the error said the write "lost
50 attempts to concurrent writers". The loops re-run a transaction for any
error that a re-run may clear, which includes a primary step-down and a
snapshot error, so a write that failed for those reasons was reported as
write contention, and the last driver error was dropped.

TxErr::Transient, TransactOpError::Transient and CommitOutcome::RerunBody
now carry the driver's error text, and each loop keeps the last one. The
exhaustion message reads "<operation>: could not commit in <N> attempts:
<cause>", and the engine logs it with its 503 warning. Each retry writes one
debug line with the operation, the attempt and the cause, so contention that
the final answer hides can be seen with the log level raised.
TransactWriteItems logs its last cause when it answers with the synthetic
TransactionConflict reasons; those reasons are unchanged.

docs/differences-from-dynamodb.md states that a single-item write on a
MongoDB table with a stream or an index answers 503 after 50 attempts where
the service answers 200, and that the server counts the 503 as a system
error, not under TransactionConflict.

Tests: retry_budget_exhausted puts the operation, the attempt count and the
cause in the message; retrying returns its cause; the conversions into TxErr
and TransactOpError keep the driver's text and a helper's Transient text.
The batch contention test replayed UnprocessedItems in a loop with no
limit and no pause, so a server that never applies the requests would
hold the job until its timeout. The loop now makes at most 200 passes,
sleeps 10 ms between two, and fails with the number of requests still
unapplied.

test_transactions_on_a_stream_table_all_commit sends TransactWriteItems
(a put, a delete and an update on disjoint keys) from six writers to a
stream table. On MongoDB the transactions conflict on the stream's
sequence counters, so a helper conflict has to re-run the whole
transaction. The test fails when a call answers 500 or 503, resends the
TransactionConflict cancellation that MongoDB answers after 50 attempts,
and checks that each counter and the stream record count show every
transaction applied once. No test combined TransactWriteItems with a
stream before.

tests/test_concurrency.py: ServiceUnavailable joins the retryable codes.
A MongoDB write that cannot commit in 50 attempts answers 503 where it
answered 400 TransactionConflictException, and the helper's client
allows the SDK a single retry, so the single-row tests would fail on the
first exhausted write.

The UpdateItem contention test accepts 400 TransactionConflictException
when EXTENDDB_TEST_BACKEND is cassandra, which settles a lost
optimistic-concurrency race that way. Every other backend keeps the
strict check.
The RFC and design document 13 listed the UnknownTransactionCommitResult
label as a trigger for re-running a transaction body. The code does not
re-run a body for that label: the commit may already have applied, so
classify_commit_error retries commitTransaction in place. Both documents
now say so.

Design document 13 said a single-item write that spends its attempts
returns StorageError::Internal. It returns StorageError::Transient, which
the engine answers as 503 ServiceUnavailable and the server counts as a
system error. The text now says that and describes the message.

"Base 50 microseconds" gave a backoff shorter than the 1 ms tick of the
tokio timer. Both documents now give the wait (a uniform 0 to 50 us times
2^min(attempt, 8)), its totals over 50 attempts, and the effect of the
tick on the first attempts.

The comments on StorageError::TransactionConflict and
StorageError::Transient, and on the engine arm that maps
TransactionConflict, described a single-item write that loses to other
writers as TransactionConflictException. The service answers that error
to a single-item write only while a TransactWriteItems holds the item,
and the MongoDB backend reports the other case as Transient.
BatchWriteItem runs seven checks on each request before it writes
anything: the key shape of a put, the nesting depth, the item size, the
attribute name sizes, the key sizes, the vector attributes and the key
shape of a delete. The tests covered the first check only, with a put whose
key has the wrong type. Moving any of the other six back into the write
loop, which applies the requests ahead of the faulty one before the 400,
left the tests green.

write_batch_tests.rs gets one test for each of the other six. Each sends a
valid put followed by a request that fails that check alone (an item of
401 KB, 40 levels of nesting, an attribute name of 70,000 bytes, a
partition key of 3,000 bytes, a vector of two components for an index of
three, a delete whose key has an extra attribute). The test requires a 400
that carries the message of that check and requires that the storage
double recorded no write, so a check that runs after the write of the put
ahead of it fails the test.

test_batch_write_validation_order.py adds the item of 401 KB and the
delete with an extra key attribute as the second request of a batch. Both
batches answer 400 ValidationException and the first put is not stored.
This commit changes comments and documents only.

batch_write_item.rs: the tables come from a HashMap, so requests are
checked in listed order within a table and the tables in map order, and
which of two table faults is reported is not defined. The sentences that
claimed an applied request is never followed by a rejection of the batch
and that a rejected batch never has an applied request now cover request
faults, and they name check_batch_counts and the HTTP layer for the limits
on the number of requests and on the request size. A storage error that a
replay does not clear still fails the batch after earlier requests were
applied (a 500 for an internal error), and a batch of which no request
applied answers 503.

is_deferrable described Transient as a write that lost every retry against
concurrent writers. Transient is a failure expected to clear on retry. The
MongoDB conflict is one case; step-down and snapshot errors on MongoDB, and
I/O errors, pool timeouts and busy databases on SQLite, are others.

The Cassandra backend raises TransactionConflict from a put or a delete
only on a table with TTL enabled. Without TTL it returns
TransactionCanceled, which is not deferred, so a batch on such a table
answers 400 after earlier requests were applied. The comment on the
TransactionConflict test is qualified and the differences row states the
case.

differences-from-dynamodb.md: the two batch measurements carry their date,
region and client retry setting (2026-10-27, us-east-1, retries off), and
the single-item contention row carries the date of its measurement
(2026-10-23).

create_table.rs names MongoDB where it says a write that lost to
single-item writers is reported as Transient. Cassandra reports it as
TransactionConflict.

Design document 13, section 5.6, said both transient-conflict signals mean
the write lost to a concurrent writer. They mean nothing was applied and
the transaction may be re-run, and a concurrent writer is the usual cause.

After an UpdateItem version miss the exhaustion message carries a fixed
sentence where other attempts carry a driver error. Design document 13, the
RFC, the differences row, StorageError::Transient and retry_budget_exhausted
call it the last error that ended an attempt.
The Cassandra pytest job failed two tests of tests/test_stream_write_contention.py
on this branch.

The batch test rejected a PutItem that answered 400 TransactionConflictException.
Cassandra settles a lost optimistic-concurrency race that way once its retries
are spent, and the UpdateItem test in the same file already accepts it when
EXTENDDB_TEST_BACKEND names that backend. The batch test now accepts it the same
way; MongoDB, SQLite and PostgreSQL keep 503 as the only allowed failure.

The transaction test expected one stream record per operation of every
transaction. Cassandra writes no stream record for a transacted write until
PR #576 lands, so the stream held only the setup puts (60 of the 240 wanted).
On that backend the test now expects the setup puts alone; the assertions that
every transaction committed and that each counter holds its writer's rounds
run unchanged on every backend.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants